Ir al contenido

Renderizar HTML a PDF con el renderer de Chrome de Artisan

El puente Artisan renderiza HTML mediante un proceso de Chrome en modo headless e importa el resultado en un documento de NextPDF como un Form XObject vectorial. El texto permanece seleccionable y buscable, en lugar de quedar rasterizado. Se adjunta un ChromeRendererConfig, se llama a writeHtmlChrome() en un documento (o se usa ChromeHtmlRenderer directamente), y Chrome se encarga del layout. Esta guía cubre la llamada de renderizado, la política de aislamiento de red, el modelo de tamaño de página y de altura del contenido, y el ciclo de vida prolongado del renderer en un worker.

Prerrequisitos iniciales:

  • El core de NextPDF y nextpdf/artisan están instalados.
  • Hay un binario de Chrome o Chromium instalado, y el usuario del worker puede ejecutarlo en modo headless. Verificarlo con chromium --headless --dump-dom about:blank antes de comenzar. El aprovisionamiento del binario y la decisión sobre el sandbox del contenedor están en la página de configuración del renderer de Chrome enlazada en Véase también.

Esta guía práctica da por hecho que es posible ejecutar un proceso de Chrome cerca de la aplicación. Para ver un primer ejemplo ejecutable, consultar el quickstart de Artisan.

Instalar el puente junto al core.

Ventana de terminal
composer require nextpdf/artisan

Instalar una build de Chrome o Chromium que el usuario del worker pueda ejecutar. En Debian o Ubuntu, usar el paquete de la distribución.

Ventana de terminal
apt-get install -y chromium

Confirmar que el binario se ejecuta en modo headless con el usuario del worker.

Ventana de terminal
chromium --headless --dump-dom about:blank

Un código de salida 0 con un DOM vacío indica que el binario y sus bibliotecas compartidas están presentes. Una salida distinta de cero equivale al mismo fallo que el puente expone como ChromeRenderException. Corregirlo aquí primero.

writeHtmlChrome() es un método del Document del core de NextPDF. Valida la entrada, resuelve el renderer de Artisan, envía el HTML a Chrome a través del Chrome DevTools Protocol (CDP), analiza el PDF devuelto e incrusta la página 0 como un Form XObject en la posición actual del cursor. Chrome se ejecuta como un proceso hijo del worker de PHP. El puente lo controla mediante CDP, en lugar de conectarse a un Chrome ejecutado por separado mediante un puerto de depuración; por tanto, no hay ningún endpoint de red que exponer ni autenticar.

El puente renderiza con una postura de red de denegación predeterminada. Cada renderizado se envuelve en una Content-Security-Policy que deniega todos los orígenes de recursos (default-src 'none') y solo permite imágenes en línea (img-src data:). El puente también bloquea todas las URL de subrecursos en la capa de transporte de CDP con Network.setBlockedURLs(['*']). Como resultado, no se cargan imágenes remotas, hojas de estilo, fuentes, scripts ni iframes incluidos en el HTML. Incrustar cada recurso en línea como un URI data:. Esta es la respuesta del puente al riesgo de server-side request forgery (SSRF) al renderizar HTML potencialmente no confiable, y se mantiene independientemente de la configuración.

El modelo de tamaño de página tiene dos modos. Cuando se proporcionan tanto el ancho como el alto (en puntos PDF), Chrome imprime exactamente a ese tamaño de papel. Cuando se omite el alto o es null, el puente mide la altura del contenido renderizado en Chrome, la convierte a puntos y añade un pequeño margen de seguridad para el reflujo (unos 14.4 puntos) para que printToPDF no genere una segunda página que el importador limitado a la página 0 recortaría.

// 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 es la única superficie de configuración y es inmutable, por lo que hay que construir una nueva instancia para cambiar un valor. ChromeRenderResult::getPdfData() devuelve los bytes del PDF. La referencia completa de opciones y los flags fijos de lanzamiento de Chrome están en la página de configuración de Artisan enlazada en Véase también.

Adjuntar la configuración a un documento, renderizar HTML de confianza y guardar.

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 se encarga del layout flex, y los números permanecen seleccionables en la salida porque la página se incrusta como un Form XObject vectorial, no como una imagen rasterizada. Para ajustar una página A4 fija, pasar el ancho y el alto en puntos.

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

En producción, construir un renderer por worker, inyectar un logger PSR-3, capturar los dos tipos de excepción distintos por separado y liberar el proceso de Chrome de forma determinista al apagar.

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();
}
}

El renderer se construye una vez y se reutiliza. El pool de navegadores subyacente mantiene activo un proceso de Chrome y lo reinicia cada 100 renderizados para acotar el crecimiento de memoria. Las dos ramas catch separan un fallo de despliegue (runtime ausente) de un fallo en tiempo de renderizado (reintentable), y ninguno de los dos bloques catch está vacío. Llamar a shutdown() al apagar el worker para liberar el proceso de Chrome, en lugar de esperar al destructor.

Construir la configuración a partir de un array de configuración del framework para obtener claves en snake-case, y fijar chromeBinaryPath en producción para usar un binario determinista.

  • El HTML vacío es una operación sin efecto. writeHtmlChrome('') devuelve el documento sin cambios.
  • Aún no hay página. Si el documento no tiene página, writeHtmlChrome() añade una antes de renderizar.
  • Los recursos remotos no se cargan, por diseño. <img src="https://..."> se renderiza vacío. Incrustar cada recurso en línea como un URI data:. Esta es la postura de aislamiento de red, no un defecto.
  • Solo se importa la página 0. El alto autoajustable añade el margen de reflujo para producir una sola página. Con un alto explícito, no se añade ningún margen y la salida coincide exactamente con el tamaño de papel solicitado, así que hay que dimensionar el alto para que se ajuste al contenido.
  • Falta el puente. Si nextpdf/artisan no está instalado, el core lanza una excepción de layout en lugar de un error fatal. Si la biblioteca chrome-php/chrome está ausente, el puente lanza ChromeNotAvailableException con el comando de instalación.
  • defaultCss y </style>. Cualquier secuencia </style> en defaultCss se elimina antes de la inyección como defensa frente al cierre prematuro del bloque de estilo. Planificar en torno a eso si el CSS se genera desde plantillas.

El primer renderizado incluye el arranque de Chrome más el layout. Los renderizados posteriores reutilizan el proceso de Chrome activo, así que el coste de arranque solo se asume unas pocas veces. Construir un renderer por worker y reutilizarlo. No crear uno por petición. Prever un pico de latencia en cada centésimo renderizado, cuando el puente reinicia el proceso de Chrome para acotar la memoria. Reflejarlo en los objetivos de latencia en lugar de tratarlo como un incidente. Combinar renderTimeout con un presupuesto de petición upstream en cualquier ruta accesible desde entrada no confiable.

  • El aislamiento de red es el control principal. El puente no permite ninguna obtención saliente de subrecursos en absoluto: CSP default-src 'none' más un bloqueo de cada URL a nivel de transporte de CDP. No implementa una lista de dominios permitidos porque no la necesita. Incrustar los recursos como URIs data:.
  • La entrada se acota antes de contactar con Chrome. El puente rechaza HTML que supere maxHtmlSize (5 MB de forma predeterminada), un URI de datos base64 sobredimensionado (una protección frente a bombas de descompresión) y cualquier etiqueta <meta http-equiv="refresh"> (que podría provocar una navegación hacia un endpoint interno). Mantener maxHtmlSize en su valor predeterminado a menos que una carga de trabajo conocida necesite más. Subirlo amplía la superficie de agotamiento de recursos.
  • El sandbox de Chrome es un control aparte. Establecer noSandbox: true lanza Chrome con --no-sandbox, lo que elimina el aislamiento de procesos de Chrome: supone una reducción real de la contención, no un flag cosmético. Dejarlo en false fuera de contenedores. Cuando el sandbox del contenedor no pueda inicializarse, ejecutar Chrome como un usuario no root en un contenedor restringido, y tratar el despliegue como un requisito de mayor confianza sobre la entrada.
  • Los registros solo contienen metadatos. Inyectar un logger PSR-3. El puente registra longitudes en bytes, dimensiones y eventos del ciclo de vida, nunca el HTML, los bytes del PDF ni el texto extraído.
  • No exponer nunca un puerto de depuración remota de Chrome. El puente no usa ninguno, y un puerto CDP abierto es un canal de control sin autenticar.

El modelo de amenazas completo (la defensa frente a SSRF, el límite del sandbox declarado explícitamente y el catálogo de modos de fallo) se documenta en la página de seguridad y operaciones de Artisan enlazada en Véase también, que fija las cláusulas pertinentes de OWASP, CWE y NIST.

Esta guía no formula por sí misma ninguna afirmación normativa de estándares. Los controles de red, aislamiento y agotamiento de recursos del puente están mapeados a OWASP ASVS, el CWE Top 25 (SSRF / consumo no controlado de recursos) y NIST SP 800-53 SC-7 en la página upstream de seguridad y operaciones de Artisan. Esta página del cookbook reformula el uso y delega esas citas normativas en esa página. El puente no realiza ninguna operación criptográfica: la firma y el cifrado son competencias del core o de la edición comercial y no se ven afectados por Artisan.