Générer un PDF avec Chrome Headless dans un projet Symfony/PHP

Il existe plusieurs librairies pour générer un PDF depuis une page HTML (Wkhtmltopdf, dompdf, …). Pour un projet client, nous devions générer des pdfs rapidement et en grande quantité. La solution avec Chrome Headless s’est avérée plus que satisfaisante pour répondre à ces problématiques.

Nous allons voir dans cet article comment mettre en place la génération d’un PDF avec Chrome, plus précisément chromium, en PHP.

Chrome Headless – print-to-pdf

Depuis la version 59 de Chrome / Chromium, le mode “headless” est disponible pour ce navigateur. Il devient alors possible d’utiliser Chrome en ligne de commande et, plus précisément, la fonctionnalité print-to-pdf. La liste des options est disponible ici : https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-printToPDF

En mode navigateur, cela correspond à cette fonctionnalité :

Mise en place pour un projet PHP/Symfony

Environnement local

Partons du principe que l’on a un projet avec PHP 7 et Composer déjà mis en place.

Pour l’environnement local sur Docker, on a besoin d’une image pour Chromium comme présentée ci-dessous.

FROM alpine:3.9

RUN apk add --no-cache chromium harfbuzz ttf-dejavu ttf-droid ttf-freefont ttf-liberation ttf-ubuntu-font-family

RUN adduser -D chrome \
&& mkdir /home/data \
&& chown -R chrome:chrome /home/data
USER chrome

ENV CHROME_BIN=/usr/bin/chromium-browser
ENV CHROME_PATH=/usr/lib/chromium/

EXPOSE 8080

CMD ["chromium-browser", \
"--headless", \
"--disable-gpu", \
"--remote-debugging-port=8080", \
"--remote-debugging-address=0.0.0.0", \
"--user-data-dir=/home/data", \
"--no-sandbox"]

Pour simplifier l’utilisation de chromium avec PHP, on utilise la librairie “jakubkulhan/chrome-devtools-protocol” :

composer require jakubkulhan/chrome-devtools-protocol

Utilisation

Nous avons mis en place une classe PHP pour centraliser la génération des PDFs depuis l’application.

Tout d’abord, on crée une instance avec le host et le port dans le constructeur de la classe.

Ensuite on appelle la fonction renderPdfFromHtml pour créer le PDF depuis le contenu d’une page HTML. Pour ce faire, on instancie dans l’ordre :

  • un context avec la possibilité de définir un timeout,
  • une session navigateur
  • une page vide

Une fois ces étapes passées, on modifie le DOM de la page que l’on a créé avec notre HTML.

Ensuite, on appelle la fonction printToPDF avec toutes les options souhaitées que l’on peut retrouver ici : https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-printToPDF Tous les appels sont envoyés en websocket.

Enfin, on récupère le contenu du PDF dans une chaîne de caractères.

class PdfGenerator implements PdfGeneratorInterface, LoggerAwareInterface
{
    use LoggerAwareTrait;

    const DEFAULT_TIMEOUT = 30;

    /**
     * @var InstanceInterface[]
     */
    private $chromiumInstances;
    /**
     * @var int
     */
    private $timeOut;

    public function __construct(array $hosts)
    {
        foreach ($hosts as $dsn) {
            list($host, $port) = explode(':', $dsn);
            $this->chromiumInstances[] = new Instance($host, $port);
        }

        $this->logger = new NullLogger();
        $this->timeOut = self::DEFAULT_TIMEOUT;
    }
    /**
     * Récupère le html brut pour l’injecter dans une page de Chrome
     * Renvoie le contenu du pdf
     */
    public function renderPdfFromHtml(string $html, array $options = []): string
    {
        $ctx = Context::withTimeout(Context::background(), $this->timeOut);
        try {
            $session = $this->createSession($ctx);
        } catch (\Throwable $e) {
            throw new PdfGenerationException($e->getMessage(), $e);
        }

        try {
            $page = $session->page();
            $page->enable($ctx);
            $navigateResponse = $page->navigate(
                $ctx,
                NavigateRequest::builder()->setUrl('about:blank')->build()
            );
            $page->setDocumentContent(
                $ctx,
                SetDocumentContentRequest::builder()
                    ->setFrameId($navigateResponse->frameId)
                    ->setHtml($html)
                    ->build()
            );
            $page->awaitLoadEventFired($ctx);

            $output = $this->createPDF($page, $ctx, $options);
        } catch (\Throwable $e) {
            $session->close();

            throw new PdfGenerationException($e->getMessage(), $e);
        }

        $session->close();

        return $output;
    }
    /**
     * Appelle de la fonction print-to-pdf de Chrome depuis une page
     */
    private function createPDF(PageDomainInterface $page, ContextInterface $ctx, array $options = []): string
    {
        $results = $page->printToPDF($ctx, $this->buildRequest($options));
        if (!isset($results->data)) {
            throw new \RuntimeException('Pdf is empty');
        }

        return base64_decode($results->data);
    }


    /**
     * Instancie la requête d’appel à la fonction print-to-pdf
     * Permet de définir les options pour le pdf
     */
    private function buildRequest(array $options = []): PrintToPDFRequest
    {
        $options = array_merge([
            'showPageNumbers' => false
        ], $options);
        $requestBuilder = PrintToPDFRequest::builder();
        $requestBuilder->setPrintBackground(true);
        if (isset($options['orientation']) && $options['orientation'] === 'landscape') {
            $requestBuilder->setLandscape(true);
        }
        $requestBuilder->setPaperWidth(8.26772); // 21 cm in inches
        $requestBuilder->setPaperHeight(11.69291); // 29.7 cm in inches
        if ($options['showPageNumbers']) {
            $requestBuilder->setDisplayHeaderFooter(true);
            $requestBuilder->setHeaderTemplate('<span></span>');
            $requestBuilder->setFooterTemplate(
                '<div style="width: 100%; padding-right: 0.7cm; text-align: right; font-size: 8px">' .
                    '<span class="pageNumber"></span>/<span class="totalPages"></span>' .
                '</div>'
            );
        }

        return $requestBuilder->build();
    }
    /**
     * Récupération d’une instance Chromium
     * Switch à l’instance suivante si aucune n’est
     * fonctionnelle
     */
    private function createSession(ContextInterface $ctx)
    {
        if (0 === count($this->chromiumInstances)) {
            throw new \RuntimeException('No available chromium instance.');
        }

        try {
            $chromiumInstance = reset($this->chromiumInstances);

            return $chromiumInstance->createSession($ctx);
        } catch (\Throwable $e) {
            array_shift($this->chromiumInstances);
            if (0 === count($this->chromiumInstances)) {
                throw $e;
            }

            return $this->createSession($ctx);
        }
    }

    public function setTimeOut(int $timeOut): PdfGeneratorInterface
    {
        $this->timeOut = $timeOut;

        return $this;
    }
}

Mise en place dans Symfony

Pour injecter ce service dans Symfony, nous avons la configuration suivante :

  • Le fichier de configuration .env avec le nom de l’instance chromium dans docker-compose ou l’adresse IP du container et le port 8080 mentionné dans l’image Docker.
#.env

CHROMIUM_SERVERS='["chromium:8080"]'
  • Le fichier services.yaml avec l’injection des instances de chromium.
#services.yaml</i>
services:
Cve\ChromeDevTools\Service\PdfGenerator:
arguments: ['%env(json:CHROMIUM_SERVERS)%']

Conclusion

Cette solution a de nombreux avantages comme la rapidité d’exécution, la possibilité de faire du multi-instance et le respect du code HTML.

Nous avons présenté ici la fonctionnalité de génération de PDF mais on peut bien sûr utiliser  les autres fonctionnalités que peut apporter Chrome.