spiritix/html-to-pdf

View on GitHub
src/Spiritix/HtmlToPdf/Converter.php

Summary

Maintainability
A
25 mins
Test Coverage
<?php
/**
 * This file is part of the spiritix/html-to-pdf package.
 *
 * @copyright Copyright (c) Matthias Isler <mi@matthias-isler.ch>
 * @license   MIT
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Spiritix\HtmlToPdf;

use Spiritix\HtmlToPdf\Input\InputInterface;
use Spiritix\HtmlToPdf\Output\OutputInterface;

/**
 * The actual HTML to PDF converter.
 *
 * @package Spiritix\HtmlToPdf
 * @author  Matthias Isler <mi@matthias-isler.ch>
 */
class Converter
{
    /**
     * Prefix for shortcut options.
     */
    const SHELL_COMMAND_PREFIX_SHORT = '-';

    /**
     * Prefix for regular options.
     */
    const SHELL_COMMAND_PREFIX_REGULAR = '--';

    /**
     * Relative path to the Intel binary.
     */
    const PATH_BINARY_INTEL = 'h4cc/wkhtmltopdf-i386/bin/wkhtmltopdf-i386';

    /**
     * Relative path to the AMD library,
     */
    const PATH_BINARY_AMD = 'h4cc/wkhtmltopdf-amd64/bin/wkhtmltopdf-amd64';

    /**
     * Input instance.
     *
     * @var InputInterface
     */
    private $input;

    /**
     * Output instance.
     *
     * @var OutputInterface
     */
    private $output;

    /**
     * The options provided to the binary library.
     *
     * @var array
     */
    private $options = [];

    /**
     * Initialize converter.
     *
     * @param InputInterface  $input
     * @param OutputInterface $output
     * @param array           $options Shortcut for setting multiple options
     */
    public function __construct(InputInterface $input, OutputInterface $output, $options = [])
    {
        $this->input = $input;
        $this->output = $output;

        $this->checkRequirements();

        if (!empty($options)) {
            $this->setOptions($options);
        }
    }

    /**
     * Returns all options.
     *
     * @return array
     */
    public function getOptions()
    {
        return $this->options;
    }

    /**
     * Returns a single option.
     *
     * @param string $key Option key
     *
     * @throws ConverterException If option has not yet been set
     *
     * @return string Option value
     */
    public function getOption($key)
    {
        if (isset($this->options[$key])) {
            return $this->options[$key];
        }

        throw new ConverterException('Option "' . $key . '" has not been set');
    }

    /**
     * Sets a single option.
     *
     * @param string $key   Option key, must not contain prefix
     * @param string $value Option value
     *
     * @throws ConverterException If option key is empty
     * @throws ConverterException If option key was provided with prefix
     *
     * @return Converter
     */
    public function setOption($key, $value = '')
    {
        if (!is_string($key) || empty($key)) {
            throw new ConverterException('Option key must not be empty');
        }

        if (substr($key, 0, 1) === self::SHELL_COMMAND_PREFIX_SHORT) {
            throw new ConverterException('Provide options without prefix');
        }

        $this->options[$key] = $value;

        return $this;
    }

    /**
     * Set multiple options at once.
     *
     * @param array $options Multiple options
     *
     * @throws ConverterException If options are empty or invalid
     *
     * @return Converter
     */
    public function setOptions($options)
    {
        if (!is_array($options) || empty($options)) {
            throw new ConverterException('Provided options are invalid');
        }

        foreach ($options as $key => $value) {

            // Convert key-only options to regular ones
            if (is_numeric($key) && !empty($value)) {
                $key = $value;
                $value = '';
            }

            $this->setOption($key, $value);
        }

        return $this;
    }

    /**
     * Runs the conversion.
     *
     * @throws ConverterException If a binary error occurred
     * @throws ConverterException If a shell error occurred
     * @throws ConverterException If no data was returned
     *
     * @return OutputInterface
     */
    public function convert()
    {
        $result = $this->executeShellCommand($this->buildCommand(), $this->input->getHtml());

        if (strpos(mb_strtolower($result['error']), 'error') !== false) {
            throw new ConverterException('Binary error: ' . $result['error']);
        }

        if ($result['result'] > 1) {
            throw new ConverterException('Shell error: ' . $result['result']);
        }

        if (mb_strlen($result['output']) === 0) {
            throw new ConverterException('No data returned');
        }

        $this->output->setPdfData($result['output']);

        return $this->output;
    }

    /**
     * Checks if the host system meets all requirements for this library.
     *
     * @throws ConverterException If program execution functions are disabled
     * @throws ConverterException If system is Windows based
     */
    private function checkRequirements()
    {
        $disabled = explode(', ', ini_get('disable_functions'));

        if (!function_exists('proc_open') || in_array('proc_open', $disabled)) {
            throw new ConverterException('HtmlToPdf requires program execution functions to be enabled');
        }

        if (DIRECTORY_SEPARATOR !== '/') {
            throw new ConverterException('HtmlToPdf requires a Unix based system');
        }
    }

    /**
     * Builds the shell command for calling the binary.
     *
     * @return string
     */
    private function buildCommand()
    {
        $optionsString = '';
        foreach ($this->options as $key => $value) {

            if (mb_strlen($key) == 1) {
                $key = self::SHELL_COMMAND_PREFIX_SHORT . $key;
            }
            else {
                $key = self::SHELL_COMMAND_PREFIX_REGULAR . $key;
            }

            $key = escapeshellcmd(trim($key));
            $value = trim($value);

            $optionsString .= $key . (empty($value) ? '' : ' ' . escapeshellarg($value)) . ' ';
        }

        $command = $this->getBinaryPath() . ' ' . $optionsString . ' - -';

        return $command;
    }

    /**
     * Executes a shell command.
     *
     * @param string $command The command to be executed
     * @param string $input   The data to be provided through the input stream
     *
     * @return array
     */
    protected function executeShellCommand($command, $input = '')
    {
        $result = [];

        $proc = proc_open(
            $command,
            array(
                0 => array('pipe', 'r'),
                1 => array('pipe', 'w'),
                2 => array('pipe', 'w')
            ),
            $pipes
        );

        fwrite($pipes[0], $input);
        fclose($pipes[0]);

        $result['output'] = stream_get_contents($pipes[1]);
        fclose($pipes[1]);

        $result['error'] = stream_get_contents($pipes[2]);
        fclose($pipes[2]);

        $result['result'] = (int) proc_close($proc);

        return $result;
    }

    /**
     * Returns the absolute path to the binary.
     *
     * @todo The hardcoded paths to the vendor folders are not optimal.
     *
     * @throws ConverterException If binary could not be found
     *
     * @return string
     */
    private function getBinaryPath()
    {
        $system = $this->executeShellCommand('uname -a | grep 64');
        $library = (!empty($system['output'])) ? self::PATH_BINARY_AMD : self::PATH_BINARY_INTEL;

        // First of all we try a path assuming that this library is installed as a package
        $binaryPath = dirname(__FILE__) . '/../../../../../' . $library;

        // If this doesn't work, we try the vendors of this package
        if (!file_exists($binaryPath)) {
            $binaryPath = dirname(__FILE__) . '/../../../vendor/' . $library;
        }

        if (!file_exists($binaryPath)) {
            throw new ConverterException('Binary could not be found');
        }

        return $binaryPath;
    }
}