summaryrefslogtreecommitdiffstats
path: root/library
diff options
context:
space:
mode:
Diffstat (limited to 'library')
-rw-r--r--library/Pdfexport/HeadlessChrome.php701
-rw-r--r--library/Pdfexport/PrintStyleSheet.php25
-rw-r--r--library/Pdfexport/PrintableHtmlDocument.php542
-rw-r--r--library/Pdfexport/ProvidedHook/Pdfexport.php151
-rw-r--r--library/Pdfexport/ShellCommand.php148
5 files changed, 1567 insertions, 0 deletions
diff --git a/library/Pdfexport/HeadlessChrome.php b/library/Pdfexport/HeadlessChrome.php
new file mode 100644
index 0000000..0612987
--- /dev/null
+++ b/library/Pdfexport/HeadlessChrome.php
@@ -0,0 +1,701 @@
+<?php
+
+/* Icinga PDF Export | (c) 2018 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Pdfexport;
+
+use Exception;
+use Icinga\Application\Logger;
+use Icinga\Application\Platform;
+use Icinga\File\Storage\StorageInterface;
+use Icinga\File\Storage\TemporaryLocalFileStorage;
+use ipl\Html\HtmlString;
+use LogicException;
+use React\ChildProcess\Process;
+use React\EventLoop\Factory;
+use React\EventLoop\TimerInterface;
+use WebSocket\Client;
+use WebSocket\ConnectionException;
+
+class HeadlessChrome
+{
+ /**
+ * Line of stderr output identifying the websocket url
+ *
+ * First matching group is the used port and the second one the browser id.
+ */
+ const DEBUG_ADDR_PATTERN = '/DevTools listening on ws:\/\/((?>\d+\.?){4}:\d+)\/devtools\/browser\/([\w-]+)/';
+
+ /** @var string */
+ const WAIT_FOR_NETWORK = 'wait-for-network';
+
+ /** @var string Javascript Promise to wait for layout initialization */
+ const WAIT_FOR_LAYOUT = <<<JS
+new Promise((fulfill, reject) => {
+ 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;
+
+ /** @var array Host and port to the remote Chrome */
+ protected $remote;
+
+ /**
+ * The document to print
+ *
+ * @var PrintableHtmlDocument
+ */
+ protected $document;
+
+ /** @var string Target Url */
+ protected $url;
+
+ /** @var StorageInterface */
+ protected $fileStorage;
+
+ /** @var array */
+ private $interceptedRequests = [];
+
+ /** @var array */
+ private $interceptedEvents = [];
+
+ /**
+ * Get the path to the Chrome binary
+ *
+ * @return string
+ */
+ public function getBinary()
+ {
+ return $this->binary;
+ }
+
+ /**
+ * Set the path to the Chrome binary
+ *
+ * @param string $binary
+ *
+ * @return $this
+ */
+ public function setBinary($binary)
+ {
+ $this->binary = $binary;
+
+ return $this;
+ }
+
+ /**
+ * Get host and port combination of the remote chrome
+ *
+ * @return array
+ */
+ public function getRemote()
+ {
+ return $this->remote;
+ }
+
+ /**
+ * Set host and port combination of a remote chrome
+ *
+ * @param string $host
+ * @param int $port
+ *
+ * @return $this
+ */
+ public function setRemote($host, $port)
+ {
+ $this->remote = [$host, $port];
+
+ return $this;
+ }
+
+ /**
+ * Get the target Url
+ *
+ * @return string
+ */
+ public function getUrl()
+ {
+ return $this->url;
+ }
+
+ /**
+ * Set the target Url
+ *
+ * @param string $url
+ *
+ * @return $this
+ */
+ public function setUrl($url)
+ {
+ $this->url = $url;
+
+ return $this;
+ }
+
+ /**
+ * Get the file storage
+ *
+ * @return StorageInterface
+ */
+ public function getFileStorage()
+ {
+ if ($this->fileStorage === null) {
+ $this->fileStorage = new TemporaryLocalFileStorage();
+ }
+
+ return $this->fileStorage;
+ }
+
+ /**
+ * Set the file storage
+ *
+ * @param StorageInterface $fileStorage
+ *
+ * @return $this
+ */
+ public function setFileStorage($fileStorage)
+ {
+ $this->fileStorage = $fileStorage;
+
+ return $this;
+ }
+
+ /**
+ * Render the given argument name-value pairs as shell-escaped string
+ *
+ * @param array $arguments
+ *
+ * @return string
+ */
+ public static function renderArgumentList(array $arguments)
+ {
+ $list = [];
+
+ foreach ($arguments as $name => $value) {
+ if ($value !== null) {
+ $value = escapeshellarg($value);
+
+ if (! is_int($name)) {
+ if (substr($name, -1) === '=') {
+ $glue = '';
+ } else {
+ $glue = ' ';
+ }
+
+ $list[] = escapeshellarg($name) . $glue . $value;
+ } else {
+ $list[] = $value;
+ }
+ } else {
+ $list[] = escapeshellarg($name);
+ }
+ }
+
+ return implode(' ', $list);
+ }
+
+ /**
+ * Use the given HTML as input
+ *
+ * @param string|PrintableHtmlDocument $html
+ * @param bool $asFile
+ * @return $this
+ */
+ public function fromHtml($html, $asFile = false)
+ {
+ if ($html instanceof PrintableHtmlDocument) {
+ $this->document = $html;
+ } else {
+ $this->document = (new PrintableHtmlDocument())
+ ->setContent(HtmlString::create($html));
+ }
+
+ if ($asFile) {
+ $path = uniqid('icingaweb2-pdfexport-') . '.html';
+ $storage = $this->getFileStorage();
+
+ $storage->create($path, $this->document->render());
+
+ $path = $storage->resolvePath($path, true);
+
+ $this->setUrl("file://$path");
+ }
+
+ return $this;
+ }
+
+ /**
+ * Export to PDF
+ *
+ * @return string
+ * @throws Exception
+ */
+ public function toPdf()
+ {
+ switch (true) {
+ case $this->remote !== null:
+ try {
+ $result = $this->jsonVersion($this->remote[0], $this->remote[1]);
+ $parts = explode('/', $result['webSocketDebuggerUrl']);
+ $pdf = $this->printToPDF(join(':', $this->remote), end($parts), isset($this->document)
+ ? $this->document->getPrintParameters()
+ : []);
+ break;
+ } catch (Exception $e) {
+ if ($this->binary === null) {
+ throw $e;
+ } else {
+ Logger::warning(
+ 'Failed to connect to remote chrome: %s:%d (%s)',
+ $this->remote[0],
+ $this->remote[1],
+ $e
+ );
+ }
+ }
+
+ // Fallback to the local binary if a remote chrome is unavailable
+ case $this->binary !== null:
+ $browserHome = $this->getFileStorage()->resolvePath('HOME');
+ $commandLine = join(' ', [
+ escapeshellarg($this->getBinary()),
+ static::renderArgumentList([
+ '--bwsi',
+ '--headless',
+ '--disable-gpu',
+ '--no-sandbox',
+ '--no-first-run',
+ '--disable-dev-shm-usage',
+ '--remote-debugging-port=0',
+ '--homedir=' => $browserHome,
+ '--user-data-dir=' => $browserHome
+ ])
+ ]);
+
+ if (Platform::isLinux()) {
+ Logger::debug('Starting browser process: HOME=%s exec %s', $browserHome, $commandLine);
+ $chrome = new Process('exec ' . $commandLine, null, ['HOME' => $browserHome]);
+ } else {
+ Logger::debug('Starting browser process: %s', $commandLine);
+ $chrome = new Process($commandLine);
+ }
+
+ $loop = Factory::create();
+
+ $killer = $loop->addTimer(10, function (TimerInterface $timer) use ($chrome) {
+ $chrome->terminate(6); // SIGABRT
+ Logger::error(
+ 'Terminated browser process after %d seconds elapsed without the expected output',
+ $timer->getInterval()
+ );
+ });
+
+ $chrome->start($loop);
+
+ $pdf = null;
+ $chrome->stderr->on('data', function ($chunk) use (&$pdf, $chrome, $loop, $killer) {
+ Logger::debug('Caught browser output: %s', $chunk);
+
+ if (preg_match(self::DEBUG_ADDR_PATTERN, trim($chunk), $matches)) {
+ $loop->cancelTimer($killer);
+
+ try {
+ $pdf = $this->printToPDF($matches[1], $matches[2], isset($this->document)
+ ? $this->document->getPrintParameters()
+ : []);
+ } catch (Exception $e) {
+ Logger::error('Failed to print PDF. An error occurred: %s', $e);
+ }
+
+ $chrome->terminate();
+ }
+ });
+
+ $chrome->on('exit', function ($exitCode, $termSignal) use ($loop, $killer) {
+ $loop->cancelTimer($killer);
+
+ Logger::debug('Browser terminated by signal %d and exited with code %d', $termSignal, $exitCode);
+ });
+
+ $loop->run();
+ }
+
+ if (empty($pdf)) {
+ throw new Exception(
+ 'Received empty response or none at all from browser.'
+ . ' Please check the logs for further details.'
+ );
+ }
+
+ return $pdf;
+ }
+
+ /**
+ * Export to PDF and save as file on disk
+ *
+ * @return string The path to the file on disk
+ */
+ public function savePdf()
+ {
+ $path = uniqid('icingaweb2-pdfexport-') . '.pdf';
+
+ $storage = $this->getFileStorage();
+ $storage->create($path, '');
+
+ $path = $storage->resolvePath($path, true);
+ file_put_contents($path, $this->toPdf());
+
+ return $path;
+ }
+
+ private function printToPDF($socket, $browserId, array $parameters)
+ {
+ $browser = new Client(sprintf('ws://%s/devtools/browser/%s', $socket, $browserId));
+
+ // Open new tab, get its id
+ $result = $this->communicate($browser, 'Target.createTarget', [
+ 'url' => 'about:blank'
+ ]);
+ if (isset($result['targetId'])) {
+ $targetId = $result['targetId'];
+ } else {
+ throw new Exception('Expected target id. Got instead: ' . json_encode($result));
+ }
+
+ $page = new Client(sprintf('ws://%s/devtools/page/%s', $socket, $targetId), ['timeout' => 300]);
+
+ // enable various events
+ $this->communicate($page, 'Log.enable');
+ $this->communicate($page, 'Network.enable');
+ $this->communicate($page, 'Page.enable');
+
+ try {
+ $this->communicate($page, 'Console.enable');
+ } catch (Exception $_) {
+ // Deprecated, might fail
+ }
+
+ if (($url = $this->getUrl()) !== null) {
+ // Navigate to target
+ $result = $this->communicate($page, 'Page.navigate', [
+ 'url' => $url
+ ]);
+ if (isset($result['frameId'])) {
+ $frameId = $result['frameId'];
+ } else {
+ throw new Exception('Expected navigation frame. Got instead: ' . json_encode($result));
+ }
+
+ // wait for page to fully load
+ $this->waitFor($page, 'Page.frameStoppedLoading', ['frameId' => $frameId]);
+ } elseif (isset($this->document)) {
+ // If there's no url to load transfer the document's content directly
+ $this->communicate($page, 'Page.setDocumentContent', [
+ 'frameId' => $targetId,
+ 'html' => $this->document->render()
+ ]);
+
+ // wait for page to fully load
+ $this->waitFor($page, 'Page.loadEventFired');
+ } else {
+ throw new LogicException('Nothing to print');
+ }
+
+ // 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,
+ ['transferMode' => 'ReturnAsBase64', 'printBackground' => true]
+ ));
+ if (isset($result['data']) && !empty($result['data'])) {
+ $pdf = base64_decode($result['data']);
+ } else {
+ throw new Exception('Expected base64 data. Got instead: ' . json_encode($result));
+ }
+
+ // close tab
+ $result = $this->communicate($browser, 'Target.closeTarget', [
+ 'targetId' => $targetId
+ ]);
+ if (! isset($result['success'])) {
+ throw new Exception('Expected close confirmation. Got instead: ' . json_encode($result));
+ }
+
+ try {
+ $browser->close();
+ } catch (ConnectionException $e) {
+ // For some reason, the browser doesn't send a response
+ Logger::debug(sprintf('Failed to close browser connection: ' . $e->getMessage()));
+ }
+
+ return $pdf;
+ }
+
+ private function renderApiCall($method, $options = null)
+ {
+ $data = [
+ 'id' => time(),
+ 'method' => $method,
+ 'params' => $options ?: []
+ ];
+
+ return json_encode($data, JSON_FORCE_OBJECT);
+ }
+
+ private function parseApiResponse($payload)
+ {
+ $data = json_decode($payload, true);
+ if (isset($data['method']) || isset($data['result'])) {
+ return $data;
+ } elseif (isset($data['error'])) {
+ throw new Exception(sprintf(
+ 'Error response (%s): %s',
+ $data['error']['code'],
+ $data['error']['message']
+ ));
+ } else {
+ throw new Exception(sprintf('Unknown response received: %s', $payload));
+ }
+ }
+
+ private function registerEvent($method, $params)
+ {
+ if (Logger::getInstance()->getLevel() === Logger::DEBUG) {
+ $shortenValues = function ($params) use (&$shortenValues) {
+ foreach ($params as &$value) {
+ if (is_array($value)) {
+ $value = $shortenValues($value);
+ } elseif (is_string($value)) {
+ $shortened = substr($value, 0, 256);
+ if ($shortened !== $value) {
+ $value = $shortened . '...';
+ }
+ }
+ }
+
+ return $params;
+ };
+ $shortenedParams = $shortenValues($params);
+
+ Logger::debug(
+ 'Received CDP event: %s(%s)',
+ $method,
+ join(',', array_map(function ($param) use ($shortenedParams) {
+ return $param . '=' . json_encode($shortenedParams[$param]);
+ }, array_keys($shortenedParams)))
+ );
+ }
+
+ if ($method === 'Network.requestWillBeSent') {
+ $this->interceptedRequests[$params['requestId']] = $params;
+ } elseif ($method === 'Network.loadingFinished') {
+ unset($this->interceptedRequests[$params['requestId']]);
+ } elseif ($method === 'Network.loadingFailed') {
+ $requestData = $this->interceptedRequests[$params['requestId']];
+ unset($this->interceptedRequests[$params['requestId']]);
+
+ Logger::error(
+ 'Headless Chrome was unable to complete a request to "%s". Error: %s',
+ $requestData['request']['url'],
+ $params['errorText']
+ );
+ } else {
+ $this->interceptedEvents[] = ['method' => $method, 'params' => $params];
+ }
+ }
+
+ private function communicate(Client $ws, $method, $params = null)
+ {
+ Logger::debug('Transmitting CDP call: %s(%s)', $method, $params ? join(',', array_keys($params)) : '');
+ $ws->send($this->renderApiCall($method, $params));
+
+ do {
+ $response = $this->parseApiResponse($ws->receive());
+ $gotEvent = isset($response['method']);
+
+ if ($gotEvent) {
+ $this->registerEvent($response['method'], $response['params']);
+ }
+ } while ($gotEvent);
+
+ Logger::debug('Received CDP result: %s', empty($response['result'])
+ ? 'none'
+ : join(',', array_keys($response['result'])));
+
+ return $response['result'];
+ }
+
+ private function waitFor(Client $ws, $eventName, array $expectedParams = null)
+ {
+ if ($eventName !== self::WAIT_FOR_NETWORK) {
+ Logger::debug(
+ 'Awaiting CDP event: %s(%s)',
+ $eventName,
+ $expectedParams ? join(',', array_keys($expectedParams)) : ''
+ );
+ } elseif (empty($this->interceptedRequests)) {
+ return null;
+ }
+
+ $wait = true;
+ $interceptedPos = -1;
+
+ do {
+ 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'];
+
+ if (! $intercepted) {
+ $this->registerEvent($method, $params);
+ }
+
+ if ($eventName === self::WAIT_FOR_NETWORK) {
+ $wait = ! empty($this->interceptedRequests);
+ } elseif ($method === $eventName) {
+ if ($expectedParams !== null) {
+ $diff = array_intersect_assoc($params, $expectedParams);
+ $wait = empty($diff);
+ } else {
+ $wait = false;
+ }
+ }
+
+ if (! $wait && $intercepted) {
+ unset($this->interceptedEvents[$interceptedPos]);
+ }
+ }
+ } while ($wait);
+
+ return $params;
+ }
+
+ /**
+ * Get the major version number of Chrome or false on failure
+ *
+ * @return int|false
+ *
+ * @throws Exception
+ */
+ public function getVersion()
+ {
+ switch (true) {
+ case $this->remote !== null:
+ try {
+ $result = $this->jsonVersion($this->remote[0], $this->remote[1]);
+ $version = $result['Browser'];
+ break;
+ } catch (Exception $e) {
+ if ($this->binary === null) {
+ throw $e;
+ } else {
+ Logger::warning(
+ 'Failed to connect to remote chrome: %s:%d (%s)',
+ $this->remote[0],
+ $this->remote[1],
+ $e
+ );
+ }
+ }
+
+ // Fallback to the local binary if a remote chrome is unavailable
+ case $this->binary !== null:
+ $command = new ShellCommand(
+ escapeshellarg($this->getBinary()) . ' ' . static::renderArgumentList(['--version']),
+ false
+ );
+
+ $output = $command->execute();
+
+ if ($command->getExitCode() !== 0) {
+ throw new \Exception($output->stderr);
+ }
+
+ $version = $output->stdout;
+ break;
+ default:
+ throw new LogicException('Set a binary or remote first');
+ }
+
+ if (preg_match('/(\d+)\.[\d.]+/', $version, $match)) {
+ return (int) $match[1];
+ }
+
+ return false;
+ }
+
+ /**
+ * Fetch result from the /json/version API endpoint
+ *
+ * @param string $host
+ * @param int $port
+ *
+ * @return bool|array
+ */
+ protected function jsonVersion($host, $port)
+ {
+ $client = new \GuzzleHttp\Client();
+ $response = $client->request('GET', sprintf('http://%s:%s/json/version', $host, $port));
+
+ if ($response->getStatusCode() !== 200) {
+ return false;
+ }
+
+ return json_decode($response->getBody(), true);
+ }
+}
diff --git a/library/Pdfexport/PrintStyleSheet.php b/library/Pdfexport/PrintStyleSheet.php
new file mode 100644
index 0000000..71a235a
--- /dev/null
+++ b/library/Pdfexport/PrintStyleSheet.php
@@ -0,0 +1,25 @@
+<?php
+
+/* Icinga PDF Export | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Pdfexport;
+
+use Icinga\Application\Icinga;
+use Icinga\Web\StyleSheet;
+
+class PrintStyleSheet extends StyleSheet
+{
+ protected function collect()
+ {
+ parent::collect();
+
+ $this->lessCompiler->setTheme(join(DIRECTORY_SEPARATOR, [
+ Icinga::app()->getModuleManager()->getModule('pdfexport')->getCssDir(),
+ 'print.less'
+ ]));
+
+ if (method_exists($this->lessCompiler, 'setThemeMode')) {
+ $this->lessCompiler->setThemeMode($this->pubPath . '/css/modes/none.less');
+ }
+ }
+}
diff --git a/library/Pdfexport/PrintableHtmlDocument.php b/library/Pdfexport/PrintableHtmlDocument.php
new file mode 100644
index 0000000..c1807e5
--- /dev/null
+++ b/library/Pdfexport/PrintableHtmlDocument.php
@@ -0,0 +1,542 @@
+<?php
+
+/* Icinga PDF Export | (c) 2019 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Pdfexport;
+
+use Icinga\Application\Icinga;
+use Icinga\Web\UrlParams;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Html\Text;
+use ipl\Html\ValidHtml;
+
+class PrintableHtmlDocument extends BaseHtmlElement
+{
+ /** @var string */
+ const DEFAULT_HEADER_FOOTER_STYLE = <<<'CSS'
+@font-face {
+ font-family: "Helvetica Neue", "Helvetica", "Arial", sans-serif;
+}
+
+header, footer {
+ width: 100%;
+ height: 21px;
+ font-size: 7px;
+ display: flex;
+ justify-content: space-between;
+ margin-left: 0.75cm;
+ margin-right: 0.75cm;
+}
+
+header > *:not(:last-child),
+footer > *:not(:last-child) {
+ margin-right: 10px;
+}
+
+span {
+ line-height: 7px;
+ white-space: nowrap;
+}
+
+p {
+ margin: 0;
+ line-height: 7px;
+ word-break: break-word;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+}
+CSS;
+
+ /** @var string Document title */
+ protected $title;
+
+ /**
+ * Paper orientation
+ *
+ * Defaults to false.
+ *
+ * @var bool
+ */
+ protected $landscape;
+
+ /**
+ * Print background graphics
+ *
+ * Defaults to false.
+ *
+ * @var bool
+ */
+ protected $printBackground;
+
+ /**
+ * Scale of the webpage rendering
+ *
+ * Defaults to 1.
+ *
+ * @var float
+ */
+ protected $scale;
+
+ /**
+ * Paper width in inches
+ *
+ * Defaults to 8.5 inches.
+ *
+ * @var float
+ */
+ protected $paperWidth;
+
+ /**
+ * Paper height in inches
+ *
+ * Defaults to 11 inches.
+ *
+ * @var float
+ */
+ protected $paperHeight;
+
+ /**
+ * Top margin in inches
+ *
+ * Defaults to 1cm (~0.4 inches).
+ *
+ * @var float
+ */
+ protected $marginTop;
+
+ /**
+ * Bottom margin in inches
+ *
+ * Defaults to 1cm (~0.4 inches).
+ *
+ * @var float
+ */
+ protected $marginBottom;
+
+ /**
+ * Left margin in inches
+ *
+ * Defaults to 1cm (~0.4 inches).
+ *
+ * @var float
+ */
+ protected $marginLeft;
+
+ /**
+ * Right margin in inches
+ *
+ * Defaults to 1cm (~0.4 inches).
+ *
+ * @var float
+ */
+ protected $marginRight;
+
+ /**
+ * Paper ranges to print, e.g., '1-5, 8, 11-13'
+ *
+ * Defaults to the empty string, which means print all pages
+ *
+ * @var string
+ */
+ protected $pageRanges;
+
+ /**
+ * Page height in pixels
+ *
+ * Minus the default vertical margins, this is 1035.
+ * If the vertical margins are zero, it's 1160.
+ * Whether there's a header or footer doesn't matter in any case.
+ *
+ * @var int
+ */
+ protected $pagePixelHeight = 1035;
+
+ /**
+ * HTML template for the print header
+ *
+ * Should be valid HTML markup with following classes used to inject printing values into them:
+ * * date: formatted print date
+ * * title: document title
+ * * url: document location
+ * * pageNumber: current page number
+ * * totalPages: total pages in the document
+ *
+ * For example, `<span class=title></span>` would generate span containing the title.
+ *
+ * Note that the header cannot exceed a height of 21px regardless of the margin's height or document's scale.
+ * With the default style, this height is separated by three lines, each accommodating 7px.
+ * Use `span`'s for single line text and `p`'s for multiline text.
+ *
+ * @var ValidHtml
+ */
+ protected $headerTemplate;
+
+ /**
+ * HTML template for the print footer
+ *
+ * Should be valid HTML markup with following classes used to inject printing values into them:
+ * * date: formatted print date
+ * * title: document title
+ * * url: document location
+ * * pageNumber: current page number
+ * * totalPages: total pages in the document
+ *
+ * For example, `<span class=title></span>` would generate span containing the title.
+ *
+ * Note that the footer cannot exceed a height of 21px regardless of the margin's height or document's scale.
+ * With the default style, this height is separated by three lines, each accommodating 7px.
+ * Use `span`'s for single line text and `p`'s for multiline text.
+ *
+ * @var ValidHtml
+ */
+ protected $footerTemplate;
+
+ /**
+ * HTML for the cover page
+ *
+ * @var ValidHtml
+ */
+ protected $coverPage;
+
+ /**
+ * Whether or not to prefer page size as defined by css
+ *
+ * Defaults to false, in which case the content will be scaled to fit the paper size.
+ *
+ * @var bool
+ */
+ protected $preferCSSPageSize;
+
+ protected $tag = 'body';
+
+ /**
+ * Get the document title
+ *
+ * @return string
+ */
+ public function getTitle()
+ {
+ return $this->title;
+ }
+
+ /**
+ * Set the document title
+ *
+ * @param string $title
+ *
+ * @return $this
+ */
+ public function setTitle($title)
+ {
+ $this->title = $title;
+
+ return $this;
+ }
+
+ /**
+ * Set page header
+ *
+ * @param ValidHtml $header
+ *
+ * @return $this
+ */
+ public function setHeader(ValidHtml $header)
+ {
+ $this->headerTemplate = $header;
+
+ return $this;
+ }
+
+ /**
+ * Set page footer
+ *
+ * @param ValidHtml $footer
+ *
+ * @return $this
+ */
+ public function setFooter(ValidHtml $footer)
+ {
+ $this->footerTemplate = $footer;
+
+ return $this;
+ }
+
+ /**
+ * Get the cover page
+ *
+ * @return ValidHtml|null
+ */
+ public function getCoverPage()
+ {
+ return $this->coverPage;
+ }
+
+ /**
+ * Set cover page
+ *
+ * @param ValidHtml $coverPage
+ *
+ * @return $this
+ */
+ public function setCoverPage(ValidHtml $coverPage)
+ {
+ $this->coverPage = $coverPage;
+
+ return $this;
+ }
+
+ /**
+ * Remove page margins
+ *
+ * @return $this
+ */
+ public function removeMargins()
+ {
+ $this->marginBottom = 0;
+ $this->marginLeft = 0;
+ $this->marginRight = 0;
+ $this->marginTop = 0;
+
+ return $this;
+ }
+
+ /**
+ * Finalize document to be printed
+ */
+ protected function assemble()
+ {
+ $this->setWrapper(new HtmlElement(
+ 'html',
+ null,
+ new HtmlElement(
+ 'head',
+ null,
+ new HtmlElement(
+ 'title',
+ null,
+ Text::create($this->title)
+ ),
+ $this->createStylesheet(),
+ $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);
+ });
+ }
+
+ /**
+ * Get the parameters for Page.printToPDF
+ *
+ * @return array
+ */
+ public function getPrintParameters()
+ {
+ $parameters = [];
+
+ if (isset($this->landscape)) {
+ $parameters['landscape'] = $this->landscape;
+ }
+
+ if (isset($this->printBackground)) {
+ $parameters['printBackground'] = $this->printBackground;
+ }
+
+ if (isset($this->scale)) {
+ $parameters['scale'] = $this->scale;
+ }
+
+ if (isset($this->paperWidth)) {
+ $parameters['paperWidth'] = $this->paperWidth;
+ }
+
+ if (isset($this->paperHeight)) {
+ $parameters['paperHeight'] = $this->paperHeight;
+ }
+
+ if (isset($this->marginTop)) {
+ $parameters['marginTop'] = $this->marginTop;
+ }
+
+ if (isset($this->marginBottom)) {
+ $parameters['marginBottom'] = $this->marginBottom;
+ }
+
+ if (isset($this->marginLeft)) {
+ $parameters['marginLeft'] = $this->marginLeft;
+ }
+
+ if (isset($this->marginRight)) {
+ $parameters['marginRight'] = $this->marginRight;
+ }
+
+ if (isset($this->pageRanges)) {
+ $parameters['pageRanges'] = $this->pageRanges;
+ }
+
+ if (isset($this->headerTemplate)) {
+ $parameters['headerTemplate'] = $this->createHeader()->render();
+ $parameters['displayHeaderFooter'] = true;
+
+ if (! isset($parameters['marginTop'])) {
+ $parameters['marginTop'] = 0.6;
+ }
+ } else {
+ $parameters['headerTemplate'] = ' '; // An empty string is ignored
+ }
+
+ if (isset($this->footerTemplate)) {
+ $parameters['footerTemplate'] = $this->createFooter()->render();
+ $parameters['displayHeaderFooter'] = true;
+
+ if (! isset($parameters['marginBottom'])) {
+ $parameters['marginBottom'] = 0.6;
+ }
+ } else {
+ $parameters['footerTemplate'] = ' '; // An empty string is ignored
+ }
+
+ if (isset($this->preferCSSPageSize)) {
+ $parameters['preferCSSPageSize'] = $this->preferCSSPageSize;
+ }
+
+ return $parameters;
+ }
+
+ /**
+ * Create CSS stylesheet
+ *
+ * @return ValidHtml
+ */
+ protected function createStylesheet(): ValidHtml
+ {
+ $app = Icinga::app();
+
+ $css = preg_replace_callback(
+ '~(?<=url\()[\'"]?([^(\'"]*)[\'"]?(?=\))~',
+ function ($matches) use ($app) {
+ if (substr($matches[1], 0, 3) !== '../') {
+ return $matches[1];
+ }
+
+ $path = substr($matches[1], 3);
+ if (substr($path, 0, 4) === 'lib/') {
+ $assetPath = substr($path, 4);
+
+ $library = null;
+ foreach ($app->getLibraries() as $candidate) {
+ if (substr($assetPath, 0, strlen($candidate->getName())) === $candidate->getName()) {
+ $library = $candidate;
+ $assetPath = ltrim(substr($assetPath, strlen($candidate->getName())), '/');
+ break;
+ }
+ }
+
+ if ($library === null) {
+ return $matches[1];
+ }
+
+ $path = $library->getStaticAssetPath() . DIRECTORY_SEPARATOR . $assetPath;
+ } elseif (substr($matches[1], 0, 14) === '../static/img?') {
+ list($_, $query) = explode('?', $matches[1], 2);
+ $params = UrlParams::fromQueryString($query);
+ if (! $app->getModuleManager()->hasEnabled($params->get('module_name'))) {
+ return $matches[1];
+ }
+
+ $module = $app->getModuleManager()->getModule($params->get('module_name'));
+ $imgRoot = $module->getBaseDir() . '/public/img/';
+ $path = realpath($imgRoot . $params->get('file'));
+ } else {
+ $path = $app->getBootstrapDirectory() . '/' . $path;
+ }
+
+ if (! $path || ! file_exists($path) || ! is_file($path)) {
+ return $matches[1];
+ }
+
+ $mimeType = @mime_content_type($path);
+ if ($mimeType === false) {
+ return $matches[1];
+ }
+
+ $fileContent = @file_get_contents($path);
+ if ($fileContent === false) {
+ return $matches[1];
+ }
+
+ return "'data:$mimeType; base64, " . base64_encode($fileContent) . "'";
+ },
+ (new PrintStyleSheet())->render(true)
+ );
+
+ return new HtmlElement('style', null, HtmlString::create($css));
+ }
+
+ /**
+ * Create layout javascript
+ *
+ * @return ValidHtml
+ */
+ protected function createLayoutScript(): ValidHtml
+ {
+ $module = Icinga::app()->getModuleManager()->getModule('pdfexport');
+ if (! method_exists($module, 'getJsDir')) {
+ $jsPath = join(DIRECTORY_SEPARATOR, [$module->getBaseDir(), 'public', 'js']);
+ } else {
+ $jsPath = $module->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)
+ );
+ }
+
+ /**
+ * Create document header
+ *
+ * @return ValidHtml
+ */
+ protected function createHeader(): ValidHtml
+ {
+ return (new HtmlDocument())
+ ->addHtml(
+ new HtmlElement('style', null, HtmlString::create(
+ static::DEFAULT_HEADER_FOOTER_STYLE
+ )),
+ new HtmlElement('header', null, $this->headerTemplate)
+ );
+ }
+
+ /**
+ * Create document footer
+ *
+ * @return ValidHtml
+ */
+ protected function createFooter(): ValidHtml
+ {
+ return (new HtmlDocument())
+ ->addHtml(
+ new HtmlElement('style', null, HtmlString::create(
+ static::DEFAULT_HEADER_FOOTER_STYLE
+ )),
+ new HtmlElement('footer', null, $this->footerTemplate)
+ );
+ }
+}
diff --git a/library/Pdfexport/ProvidedHook/Pdfexport.php b/library/Pdfexport/ProvidedHook/Pdfexport.php
new file mode 100644
index 0000000..113eff0
--- /dev/null
+++ b/library/Pdfexport/ProvidedHook/Pdfexport.php
@@ -0,0 +1,151 @@
+<?php
+
+/* Icinga PDF Export | (c) 2018 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Pdfexport\ProvidedHook;
+
+use Exception;
+use Icinga\Application\Config;
+use Icinga\Application\Hook;
+use Icinga\Application\Hook\PdfexportHook;
+use Icinga\Application\Icinga;
+use Icinga\Module\Pdfexport\HeadlessChrome;
+use Icinga\Module\Pdfexport\PrintableHtmlDocument;
+use iio\libmergepdf\Driver\TcpdiDriver;
+use iio\libmergepdf\Merger;
+
+class Pdfexport extends PdfexportHook
+{
+ public static function first()
+ {
+ $pdfexport = null;
+
+ if (Hook::has('Pdfexport')) {
+ $pdfexport = Hook::first('Pdfexport');
+
+ if (! $pdfexport->isSupported()) {
+ throw new Exception(
+ sprintf("Can't export: %s does not support exporting PDFs", get_class($pdfexport))
+ );
+ }
+ }
+
+ if (! $pdfexport) {
+ throw new Exception("Can't export: No module found which provides PDF export");
+ }
+
+ return $pdfexport;
+ }
+
+ public static function getBinary()
+ {
+ return Config::module('pdfexport')->get('chrome', 'binary', '/usr/bin/google-chrome');
+ }
+
+ public static function getForceTempStorage()
+ {
+ return (bool) Config::module('pdfexport')->get('chrome', 'force_temp_storage', '0');
+ }
+
+ public static function getHost()
+ {
+ return Config::module('pdfexport')->get('chrome', 'host');
+ }
+
+ public static function getPort()
+ {
+ return Config::module('pdfexport')->get('chrome', 'port', 9222);
+ }
+
+ public function isSupported()
+ {
+ try {
+ return $this->chrome()->getVersion() >= 59;
+ } catch (Exception $e) {
+ return false;
+ }
+ }
+
+ public function htmlToPdf($html)
+ {
+ // Keep reference to the chrome object because it is using temp files which are automatically removed when
+ // the object is destructed
+ $chrome = $this->chrome();
+
+ $pdf = $chrome->fromHtml($html, static::getForceTempStorage())->toPdf();
+
+ if ($html instanceof PrintableHtmlDocument && ($coverPage = $html->getCoverPage()) !== null) {
+ $coverPagePdf = $chrome
+ ->fromHtml(
+ (new PrintableHtmlDocument())
+ ->add($coverPage)
+ ->addAttributes($html->getAttributes())
+ ->removeMargins(),
+ static::getForceTempStorage()
+ )
+ ->toPdf();
+
+ $merger = new Merger(new TcpdiDriver());
+ $merger->addRaw($coverPagePdf);
+ $merger->addRaw($pdf);
+
+ $pdf = $merger->merge();
+ }
+
+ return $pdf;
+ }
+
+ public function streamPdfFromHtml($html, $filename)
+ {
+ $filename = basename($filename, '.pdf') . '.pdf';
+
+ // Keep reference to the chrome object because it is using temp files which are automatically removed when
+ // the object is destructed
+ $chrome = $this->chrome();
+
+ $pdf = $chrome->fromHtml($html, static::getForceTempStorage())->toPdf();
+
+ if ($html instanceof PrintableHtmlDocument && ($coverPage = $html->getCoverPage()) !== null) {
+ $coverPagePdf = $chrome
+ ->fromHtml(
+ (new PrintableHtmlDocument())
+ ->add($coverPage)
+ ->addAttributes($html->getAttributes())
+ ->removeMargins(),
+ static::getForceTempStorage()
+ )
+ ->toPdf();
+
+ $merger = new Merger(new TcpdiDriver());
+ $merger->addRaw($coverPagePdf);
+ $merger->addRaw($pdf);
+
+ $pdf = $merger->merge();
+ }
+
+ Icinga::app()->getResponse()
+ ->setHeader('Content-Type', 'application/pdf', true)
+ ->setHeader('Content-Disposition', "inline; filename=\"$filename\"", true)
+ ->setBody($pdf)
+ ->sendResponse();
+
+ exit;
+ }
+
+ /**
+ * Create an instance of HeadlessChrome from configuration
+ *
+ * @return HeadlessChrome
+ */
+ protected function chrome()
+ {
+ $chrome = new HeadlessChrome();
+ $chrome->setBinary(static::getBinary());
+
+ if (($host = static::getHost()) !== null) {
+ $chrome->setRemote($host, static::getPort());
+ }
+
+ return $chrome;
+ }
+}
diff --git a/library/Pdfexport/ShellCommand.php b/library/Pdfexport/ShellCommand.php
new file mode 100644
index 0000000..c669773
--- /dev/null
+++ b/library/Pdfexport/ShellCommand.php
@@ -0,0 +1,148 @@
+<?php
+
+/* Icinga PDF Export | (c) 2018 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Pdfexport;
+
+class ShellCommand
+{
+ /** @var string Command to execute */
+ protected $command;
+
+ /** @var int Exit code of the command */
+ protected $exitCode;
+
+ /** @var resource Process resource */
+ protected $resource;
+
+ /**
+ * Create a new command
+ *
+ * @param string $command The command to execute
+ * @param bool $escape Whether to escape the command
+ */
+ public function __construct($command, $escape = true)
+ {
+ $command = (string) $command;
+
+ $this->command = $escape ? escapeshellcmd($command) : $command;
+ }
+
+ /**
+ * Get the exit code of the command
+ *
+ * @return int
+ */
+ public function getExitCode()
+ {
+ return $this->exitCode;
+ }
+
+ /**
+ * Get the status of the command
+ *
+ * @return object
+ */
+ public function getStatus()
+ {
+ $status = (object) proc_get_status($this->resource);
+ if ($status->running === false && $this->exitCode === null) {
+ // The exit code is only valid the first time proc_get_status is
+ // called in terms of running false, hence we capture it
+ $this->exitCode = $status->exitcode;
+ }
+
+ return $status;
+ }
+
+ /**
+ * Execute the command
+ *
+ * @return object
+ *
+ * @throws \Exception
+ */
+ public function execute()
+ {
+ if ($this->resource !== null) {
+ throw new \Exception('Command already started');
+ }
+
+ $descriptors = [
+ ['pipe', 'r'], // stdin
+ ['pipe', 'w'], // stdout
+ ['pipe', 'w'] // stderr
+ ];
+
+ $this->resource = proc_open(
+ $this->command,
+ $descriptors,
+ $pipes
+ );
+
+ if (! is_resource($this->resource)) {
+ throw new \Exception(sprintf(
+ "Can't fork '%s'",
+ $this->command
+ ));
+ }
+
+ $namedpipes = (object) [
+ 'stdin' => &$pipes[0],
+ 'stdout' => &$pipes[1],
+ 'stderr' => &$pipes[2]
+ ];
+
+ fclose($namedpipes->stdin);
+
+ $read = [$namedpipes->stderr, $namedpipes->stdout];
+ $origRead = $read;
+ $write = null; // stdin not handled
+ $except = null;
+ $stdout = '';
+ $stderr = '';
+
+ stream_set_blocking($namedpipes->stdout, 0); // non-blocking
+ stream_set_blocking($namedpipes->stderr, 0);
+
+ while (stream_select($read, $write, $except, 0, 20000) !== false) {
+ foreach ($read as $pipe) {
+ if ($pipe === $namedpipes->stdout) {
+ $stdout .= stream_get_contents($pipe);
+ }
+
+ if ($pipe === $namedpipes->stderr) {
+ $stderr .= stream_get_contents($pipe);
+ }
+ }
+
+ foreach ($origRead as $i => $str) {
+ if (feof($str)) {
+ unset($origRead[$i]);
+ }
+ }
+
+ if (empty($origRead)) {
+ break;
+ }
+
+ // Reset pipes
+ $read = $origRead;
+ }
+
+ fclose($namedpipes->stderr);
+ fclose($namedpipes->stdout);
+
+ $exitCode = proc_close($this->resource);
+ if ($this->exitCode === null) {
+ $this->exitCode = $exitCode;
+ }
+
+ $this->resource = null;
+
+ return (object) [
+ 'stdout' => $stdout,
+ 'stderr' => $stderr
+ ];
+ }
+}