diff options
Diffstat (limited to '')
-rw-r--r-- | library/Pdfexport/HeadlessChrome.php | 701 | ||||
-rw-r--r-- | library/Pdfexport/PrintStyleSheet.php | 25 | ||||
-rw-r--r-- | library/Pdfexport/PrintableHtmlDocument.php | 542 | ||||
-rw-r--r-- | library/Pdfexport/ProvidedHook/Pdfexport.php | 151 | ||||
-rw-r--r-- | library/Pdfexport/ShellCommand.php | 148 |
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 + ]; + } +} |