Aller au contenu

Générer un PDF depuis du HTML avec le renderer Chrome d'Artisan

Le pont Artisan effectue le rendu du HTML via un processus headless Chrome, puis importe le résultat dans un document NextPDF sous forme de Form XObject vectoriel. Le texte reste sélectionnable et indexable, au lieu d’être rastérisé. Tu associes un ChromeRendererConfig, tu appelles writeHtmlChrome() sur un document (ou tu utilises ChromeHtmlRenderer directement), et Chrome prend en charge la mise en page. Ce guide couvre l’appel de rendu, la politique d’isolation réseau, le modèle de taille de page et de hauteur de contenu, ainsi que le cycle de vie du renderer de longue durée pour un worker.

Les prérequis, d’emblée :

  • Le cœur NextPDF et nextpdf/artisan sont installés.
  • Un binaire Chrome ou Chromium est installé, et l’utilisateur du worker peut le lancer en mode headless. Vérifie-le avec chromium --headless --dump-dom about:blank avant de commencer. Le provisionnement du binaire et le choix lié au sandbox du conteneur sont traités sur la page de configuration du renderer Chrome référencée sous Voir aussi.

Il s’agit d’un guide pratique. Il suppose que tu peux lancer un processus Chrome à proximité de l’application. Pour un premier exemple directement exécutable, consulte le démarrage rapide d’Artisan.

Installe le pont aux côtés du cœur.

Fenêtre de terminal
composer require nextpdf/artisan

Installe une version de Chrome ou Chromium que l’utilisateur du worker peut lancer. Sous Debian ou Ubuntu, utilise le paquet de la distribution.

Fenêtre de terminal
apt-get install -y chromium

Confirme que le binaire s’exécute en mode headless avec l’utilisateur du worker.

Fenêtre de terminal
chromium --headless --dump-dom about:blank

Un code de sortie 0 accompagné d’un DOM vide signifie que le binaire et ses bibliothèques partagées sont présents. Un code de sortie non nul correspond au même échec que le pont fait remonter sous forme de ChromeRenderException. Corrige ce point ici en premier.

writeHtmlChrome() est une méthode du Document du cœur NextPDF. Elle valide l’entrée, résout le renderer Artisan, envoie le HTML à Chrome via le Chrome DevTools Protocol (CDP), analyse le PDF renvoyé et incorpore la page 0 sous forme de Form XObject à la position courante du curseur. Chrome s’exécute en tant que processus enfant du worker PHP. Le pont le pilote via CDP au lieu de se connecter à un Chrome lancé séparément sur un port de débogage, si bien qu’aucun point d’accès réseau n’a besoin d’être exposé ni authentifié.

Le pont effectue le rendu derrière une posture réseau de refus par défaut. Chaque rendu est encapsulé dans une Content-Security-Policy qui refuse toutes les origines de ressources (default-src 'none') et n’autorise que les images en ligne (img-src data:). Le pont bloque aussi chaque URL de sous-ressource à la couche de transport CDP avec Network.setBlockedURLs(['*']). De ce fait, une image, une feuille de style, une police, un script ou un iframe distant présent dans ton HTML ne se charge pas. Intègre chaque ressource en ligne sous forme d’URI data:. C’est ainsi que le pont répond au risque de falsification de requête côté serveur (SSRF) lors du rendu de HTML potentiellement non fiable, et cette protection s’applique quelle que soit la configuration.

Le modèle de taille de page comporte deux modes. Quand tu fournis à la fois la largeur et la hauteur (en points PDF), Chrome imprime exactement au format papier demandé. Quand la hauteur est omise ou null, le pont mesure la hauteur du contenu rendu dans Chrome, la convertit en points et ajoute une petite marge de sécurité pour le reflux (environ 14,4 points), afin que printToPDF ne déborde pas sur une seconde page que l’importateur, limité à la page 0, tronquerait.

// On a NextPDF core Document (the HasTextOutput concern):
writeHtmlChrome(string $html, ?float $width = null, ?float $height = null): static
// The standalone renderer:
new ChromeHtmlRenderer(ChromeRendererConfig $config, ?LoggerInterface $logger = null)
ChromeHtmlRenderer::render(string $html, float $widthPt, float $heightPt = 0.0): ChromeRenderResult
ChromeHtmlRenderer::close(): void
// The configuration value object (final readonly):
new ChromeRendererConfig(
?string $chromeBinaryPath = null,
int $renderTimeout = 30,
string $defaultCss = '',
int $maxHtmlSize = 5_000_000,
bool $noSandbox = false,
)
ChromeRendererConfig::fromArray(array $config): self

ChromeRendererConfig est l’unique surface de configuration, et elle est immuable : construis donc une nouvelle instance pour changer une valeur. ChromeRenderResult::getPdfData() renvoie les octets du PDF. La référence complète des options et des drapeaux de lancement Chrome figés se trouve sur la page de configuration d’Artisan référencée sous Voir aussi.

Associe la config à un document, rends du HTML de confiance, puis enregistre.

render-quickstart.php
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use NextPDF\Artisan\ChromeRendererConfig;
use NextPDF\Core\Document;
$config = new ChromeRendererConfig(
chromeBinaryPath: '/usr/bin/chromium',
);
$document = Document::createStandalone();
$document->setChromeRendererConfig($config);
$document->addPage();
$document->writeHtmlChrome('
<div style="display: flex; gap: 20px; font-family: sans-serif;">
<div style="flex: 1; background: #f0f0f0; padding: 24px;">
<h2>Revenue</h2>
<p style="font-size: 2em; color: #2563eb;">$124,500</p>
</div>
<div style="flex: 1; background: #f0f0f0; padding: 24px;">
<h2>Orders</h2>
<p style="font-size: 2em; color: #16a34a;">1,847</p>
</div>
</div>
');
$document->save('/tmp/report.pdf');

Chrome gère la mise en page flex, et les chiffres restent sélectionnables dans la sortie parce que la page est incorporée sous forme de Form XObject vectoriel, et non comme image matricielle. Pour tenir sur une page A4 fixe, passe la largeur et la hauteur en points.

explicit A4 page size
$document->writeHtmlChrome($html, width: 595.28, height: 841.89);

En production, construis un seul renderer par worker, injecte un logger PSR-3, intercepte séparément les deux types d’exception, et libère le processus Chrome de manière déterministe à l’arrêt.

ReportRenderer.php
<?php
declare(strict_types=1);
use NextPDF\Artisan\ChromeHtmlRenderer;
use NextPDF\Artisan\ChromeRendererConfig;
use NextPDF\Artisan\Exception\ChromeNotAvailableException;
use NextPDF\Artisan\Exception\ChromeRenderException;
use Psr\Log\LoggerInterface;
final class ReportRenderer
{
private ChromeHtmlRenderer $renderer;
public function __construct(LoggerInterface $logger)
{
$config = ChromeRendererConfig::fromArray([
'chrome_binary' => getenv('CHROME_BINARY') ?: null,
'render_timeout' => 45,
'max_html_size' => 2_000_000,
'no_sandbox' => (bool) getenv('CHROME_NO_SANDBOX'),
]);
$this->renderer = new ChromeHtmlRenderer($config, $logger);
}
public function render(string $html, float $widthPt, float $heightPt = 0.0): string
{
try {
return $this->renderer->render($html, $widthPt, $heightPt)->getPdfData();
} catch (ChromeNotAvailableException $exception) {
// Deployment fault: the Chrome runtime is missing. Page on-call.
throw $exception;
} catch (ChromeRenderException $exception) {
// Render-time fault: timeout, crash, or empty output. Retryable once.
throw $exception;
}
}
public function shutdown(): void
{
$this->renderer->close();
}
}

Le renderer est construit une seule fois et réutilisé. Le pool de navigateurs sous-jacent maintient un processus Chrome en vie et le redémarre tous les 100 rendus pour borner la croissance mémoire. Les deux blocs catch séparent une panne de déploiement (runtime manquant) d’une panne au moment du rendu (réessayable), et aucun des deux blocs catch n’est vide. Appelle shutdown() à l’arrêt du worker pour libérer le processus Chrome au lieu d’attendre le destructeur.

Construis la config à partir d’un tableau de configuration du framework afin d’obtenir des clés en snake_case, et fige chromeBinaryPath en production pour utiliser un binaire déterministe.

  • Un HTML vide est sans effet. writeHtmlChrome('') renvoie le document inchangé.
  • Aucune page pour l’instant. Si le document n’a aucune page, writeHtmlChrome() en ajoute une avant le rendu.
  • Les ressources distantes ne se chargent pas — par conception. <img src="https://..."> est rendu vide. Intègre chaque ressource en ligne sous forme d’URI data:. Il s’agit de la posture d’isolation réseau, pas d’un défaut.
  • Seule la page 0 est importée. L’ajustement automatique de la hauteur ajoute la marge de reflux afin qu’une seule page soit produite. Avec une hauteur explicite, aucune marge n’est ajoutée et la sortie correspond exactement à la taille de papier demandée : dimensionne donc la hauteur pour couvrir tout ton contenu.
  • Pont manquant. Si nextpdf/artisan n’est pas installé, le cœur lève une exception de mise en page plutôt qu’une erreur fatale. Si la bibliothèque chrome-php/chrome est absente, le pont lève ChromeNotAvailableException avec la commande d’installation.
  • defaultCss et </style>. Toute séquence </style> présente dans defaultCss est retirée avant l’injection, afin d’empêcher l’échappement de la balise style. Tiens-en compte si tu génères ton CSS depuis un modèle.

Le premier rendu supporte le coût du démarrage de Chrome et de la mise en page. Les rendus suivants réutilisent le processus Chrome déjà actif, si bien que le coût de démarrage n’est payé que rarement. Construis un seul renderer par worker et réutilise-le. N’en crée pas un par requête. Attends-toi à un pic de latence tous les 100 rendus, lorsque le pont redémarre le processus Chrome pour borner la mémoire. Tiens-en compte dans tes objectifs de latence au lieu de le traiter comme un incident. Aligne renderTimeout sur un budget de requête en amont pour tout chemin accessible depuis une entrée non fiable.

  • L’isolation réseau est le contrôle principal. Le pont n’autorise aucune récupération de sous-ressource sortante — CSP default-src 'none' plus un blocage de chaque URL au niveau du transport CDP. Il n’implémente pas de liste d’autorisation par domaine parce qu’il n’en a pas besoin. Intègre les ressources en ligne sous forme d’URI data:.
  • L’entrée est bornée avant que Chrome ne soit contacté. Le pont rejette tout HTML dépassant maxHtmlSize (5 Mo par défaut), tout URI de données base64 surdimensionné (une protection contre les bombes de décompression), et toute balise <meta http-equiv="refresh"> (qui pourrait déclencher une navigation vers un point d’accès interne). Garde maxHtmlSize à sa valeur par défaut tant qu’une charge de travail connue n’en exige pas plus. L’augmenter élargit la surface d’épuisement des ressources.
  • Le sandbox de Chrome est un contrôle distinct. Définir noSandbox: true lance Chrome avec --no-sandbox, ce qui supprime l’isolation des processus de Chrome — une réduction réelle du confinement, pas un drapeau cosmétique. Laisse-le à false en dehors des conteneurs. Quand le sandbox du conteneur ne peut pas s’initialiser, lance Chrome sous un utilisateur non root dans un conteneur restreint, et considère le déploiement comme une exigence de confiance plus élevée vis-à-vis de l’entrée.
  • Les journaux ne contiennent que des métadonnées. Injecte un logger PSR-3. Le pont journalise les longueurs en octets, les dimensions et les événements de cycle de vie, jamais le HTML, les octets du PDF ni le texte extrait.
  • N’expose jamais de port de débogage distant Chrome. Le pont n’en utilise pas, et un port CDP ouvert est un canal de contrôle non authentifié.

Le modèle de menace complet — la défense contre la SSRF, la limite du sandbox énoncée explicitement et le catalogue des modes de défaillance — se trouve sur la page de sécurité et d’exploitation d’Artisan référencée sous Voir aussi, qui fixe les clauses pertinentes d’OWASP, du CWE et du NIST.

Ce guide ne formule aucune revendication normative de conformité qui lui soit propre. Les contrôles réseau, d’isolation et d’épuisement des ressources du pont sont mappés à OWASP ASVS, au CWE Top 25 (SSRF / consommation de ressources non maîtrisée) et à NIST SP 800-53 SC-7 sur la page amont de sécurité et d’exploitation d’Artisan. Cette page du Cookbook reformule l’usage et renvoie ces citations normatives à cette page. Le pont n’effectue aucune opération cryptographique — la signature et le chiffrement relèvent du cœur ou de l’édition commerciale, et ne sont pas affectés par Artisan.