콘텐츠로 이동

Artisan Chrome 렌더러로 HTML을 PDF로 렌더링하기

Artisan 브리지는 헤드리스 Chrome 프로세스를 통해 HTML을 렌더링하고, 그 결과를 벡터 Form XObject로 NextPDF 문서에 가져옵니다. 텍스트는 래스터화되지 않으므로 선택하고 검색할 수 있습니다. ChromeRendererConfig를 연결하고 문서에서 writeHtmlChrome() 메서드를 호출하면(또는 ChromeHtmlRenderer를 직접 사용하면) Chrome이 레이아웃을 처리합니다. 이 가이드는 렌더링 호출, 네트워크 격리 정책, 페이지 크기 및 콘텐츠 높이 모델, 워커를 위한 장기 실행 렌더러 수명 주기를 다룹니다.

먼저 사전 요구 사항부터 살펴보겠습니다.

  • NextPDF 코어와 nextpdf/artisan이 설치되어 있습니다.
  • Chrome 또는 Chromium 바이너리가 설치되어 있고, 워커 사용자가 이를 헤드리스로 실행할 수 있어야 합니다. 시작하기 전에 chromium --headless --dump-dom about:blank으로 확인하세요. 바이너리 프로비저닝과 컨테이너 샌드박스 결정은 함께 보기 섹션에 링크된 Chrome 렌더러 설정 페이지를 참고하세요.

이 문서는 실습 가이드입니다. 애플리케이션과 가까운 실행 환경에서 Chrome 프로세스를 실행할 수 있다고 가정합니다. 처음 실행할 수 있는 예제는 Artisan 빠른 시작을 참고하세요.

코어와 함께 브리지를 설치합니다.

Terminal window
composer require nextpdf/artisan

워커 사용자가 실행할 수 있는 Chrome 또는 Chromium 빌드를 설치합니다. Debian 또는 Ubuntu에서는 배포판 패키지를 사용하세요.

Terminal window
apt-get install -y chromium

바이너리가 워커 사용자 권한으로 헤드리스 실행되는지 확인하세요.

Terminal window
chromium --headless --dump-dom about:blank

빈 DOM과 함께 종료 코드 0이 나오면 바이너리와 그 공유 라이브러리가 존재한다는 의미입니다. 0이 아닌 종료 코드는 브리지가 ChromeRenderException으로 드러내는 것과 동일한 실패입니다. 먼저 이 단계에서 문제를 해결하세요.

writeHtmlChrome()은 NextPDF 코어 Document의 메서드입니다. 이 메서드는 입력을 검증하고, Artisan 렌더러를 확인한 뒤, Chrome DevTools Protocol(CDP)을 통해 HTML을 Chrome으로 전송하고, 반환된 PDF를 파싱한 다음, 페이지 0을 현재 커서 위치에 Form XObject로 임베드합니다. Chrome은 PHP 워커의 자식 프로세스로 실행됩니다. 브리지는 디버깅 포트를 통해 별도로 실행 중인 Chrome에 연결하지 않고 CDP를 통해 Chrome을 제어하므로, 노출하거나 인증해야 할 네트워크 엔드포인트가 없습니다.

브리지는 기본 거부(deny-by-default) 네트워크 정책을 적용한 상태에서 렌더링합니다. 모든 렌더링은 모든 리소스 출처를 거부하고(default-src 'none') 인라인 이미지만 허용하는(img-src data:) Content-Security-Policy로 래핑됩니다. 또한 브리지는 Network.setBlockedURLs(['*'])으로 CDP 전송 계층에서 모든 하위 리소스 URL을 차단합니다. 그 결과, HTML에 포함된 원격 이미지, 스타일시트, 글꼴, 스크립트 또는 iframe은 로드되지 않습니다. 모든 자산을 data: URI로 인라인 처리하세요. 이는 신뢰할 수 없는 HTML을 렌더링할 때 발생할 수 있는 서버 측 요청 위조(SSRF) 위험에 대한 브리지의 대응이며, 구성과 관계없이 적용됩니다.

페이지 크기 모델에는 두 가지 모드가 있습니다. 너비와 높이(PDF 포인트 단위)를 모두 지정하면 Chrome은 정확히 해당 용지 크기로 인쇄합니다. 높이를 생략하거나 null로 지정하면, 브리지는 Chrome에서 렌더링된 콘텐츠 높이를 측정해 포인트로 변환한 다음, 페이지 0만 가져오는 임포터가 잘라 버릴 두 번째 페이지로 printToPDF 출력이 넘어가지 않도록 작은 리플로 안전 버퍼(약 14.4 포인트)를 추가합니다.

// 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는 단일 구성 진입점이며 불변(immutable)이므로, 값을 변경하려면 새 인스턴스를 생성하세요. ChromeRenderResult::getPdfData()는 PDF 바이트를 반환합니다. 전체 옵션 참조와 고정된 Chrome 실행 플래그는 함께 보기 섹션에 링크된 Artisan 구성 페이지에 있습니다.

구성을 문서에 연결하고, 신뢰할 수 있는 HTML을 렌더링한 다음, 저장합니다.

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이 플렉스 레이아웃을 처리하며, 페이지가 래스터 이미지가 아니라 벡터 Form XObject로 임베드되므로 출력에서 숫자를 선택할 수 있습니다. 고정된 A4 페이지에 맞추려면 너비와 높이를 포인트 단위로 전달하세요.

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

프로덕션에서는 워커당 하나의 렌더러를 생성하고, PSR-3 로거를 주입하며, 별개의 예외 유형 두 가지를 각각 캐치하고, 종료 시 Chrome 프로세스를 확실하게 해제하세요.

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

렌더러는 한 번 생성한 뒤 재사용합니다. 내부 브라우저 풀은 하나의 Chrome 프로세스를 유지하며, 메모리 증가를 제한하기 위해 렌더링 100회마다 이를 재시작합니다. 두 개의 catch 분기는 배포 결함(런타임 누락)과 렌더링 시점 결함(재시도 가능)을 구분하며, 두 catch 블록 모두 비워 두지 않습니다. 소멸자를 기다리지 말고, 워커 종료 시 shutdown()을 호출하여 Chrome 프로세스를 해제하세요.

snake-case 키를 사용하려면 프레임워크 구성 배열에서 구성을 생성하고, 확정된 바이너리를 사용하도록 프로덕션에서 chromeBinaryPath를 고정하세요.

  • 빈 HTML은 아무 작업도 수행하지 않습니다. writeHtmlChrome('')은 문서를 변경하지 않고 그대로 반환합니다.
  • 아직 페이지가 없는 경우. 문서에 페이지가 없으면 writeHtmlChrome()은 렌더링 전에 페이지를 하나 추가합니다.
  • 원격 자산은 로드되지 않습니다 — 의도된 동작입니다. <img src="https://...">는 빈 상태로 렌더링됩니다. 모든 자산을 data: URI로 인라인 처리하세요. 이는 네트워크 격리 정책이며 결함이 아닙니다.
  • 페이지 0만 가져옵니다. 높이 자동 맞춤은 리플로 버퍼를 추가하여 단일 페이지가 생성되도록 합니다. 높이를 명시적으로 지정하면 버퍼가 추가되지 않고 출력이 요청한 용지 크기와 정확히 일치하므로, 콘텐츠에 맞게 높이를 설정하세요.
  • 브리지 누락. nextpdf/artisan이 설치되어 있지 않으면, 코어는 치명적 오류 대신 레이아웃 예외를 발생시킵니다. chrome-php/chrome 라이브러리가 없으면 브리지는 설치 명령과 함께 ChromeNotAvailableException을 발생시킵니다.
  • defaultCss</style>. 스타일 탈출 방어책으로, defaultCss</style> 시퀀스가 들어 있으면 주입 전에 제거됩니다. CSS를 템플릿화하는 경우 이를 고려하여 설계하세요.

첫 번째 렌더링에는 Chrome 시작 비용과 레이아웃 비용이 발생합니다. 이후 렌더링은 실행 중인 Chrome 프로세스를 재사용하므로 시작 비용이 거의 들지 않습니다. 워커당 렌더러 하나를 생성해 재사용하세요. 요청마다 새로 생성하지 마세요. 브리지가 메모리를 제한하기 위해 Chrome 프로세스를 재시작하는 100번째 렌더링마다 지연 시간이 급증할 수 있습니다. 이를 인시던트로 취급하기보다는 지연 시간 목표에 반영하세요. 신뢰할 수 없는 입력이 도달할 수 있는 모든 경로에서는 renderTimeout을 상위 요청 예산에 맞춰 구성하세요.

  • 네트워크 격리가 주요 통제 수단입니다. 브리지는 외부로 나가는 하위 리소스 페치를 전혀 허용하지 않습니다 — CSP default-src 'none'과 모든 URL에 대한 CDP 전송 수준 차단을 함께 사용합니다. 도메인 허용 목록이 필요 없으므로 브리지는 이를 구현하지 않습니다. 자산을 data: URI로 인라인 처리하세요.
  • Chrome에 접근하기 전에 입력이 제한됩니다. 브리지는 maxHtmlSize(기본값 5MB)를 초과하는 HTML, 지나치게 큰 base64 데이터 URI(압축 해제 폭탄 방어책), 그리고 (내부 엔드포인트 탐색을 유발할 수 있는) 모든 <meta http-equiv="refresh"> 태그를 거부합니다. 알려진 워크로드에서 더 큰 값이 필요한 경우가 아니라면 maxHtmlSize를 기본값으로 유지하세요. 이를 높이면 리소스 고갈 공격면이 넓어집니다.
  • Chrome 샌드박스는 별도의 통제 수단입니다. noSandbox: true를 설정하면 Chrome이 --no-sandbox로 실행되어 Chrome 프로세스 격리가 제거됩니다 — 이는 겉치레 플래그가 아니라 격리 수준을 실질적으로 낮추는 설정입니다. 컨테이너 외부에서는 이 값을 false로 유지하세요. 컨테이너 샌드박스를 초기화할 수 없는 경우, 제약된 컨테이너에서 Chrome을 비루트 사용자로 실행하고, 해당 배포에는 입력에 대해 더 높은 신뢰 요구 사항을 적용하세요.
  • 로그에는 메타데이터만 포함됩니다. PSR-3 로거를 주입하세요. 브리지는 바이트 길이, 치수, 수명 주기 이벤트를 로깅하며, HTML, PDF 바이트, 추출된 텍스트는 절대 로깅하지 않습니다.
  • Chrome 원격 디버깅 포트를 절대 노출하지 마세요. 브리지는 그러한 포트를 사용하지 않으며, 열린 CDP 포트는 인증되지 않은 제어 채널입니다.

전체 위협 모델(SSRF 방어, 명시적으로 기술된 샌드박스 경계, 실패 모드 카탈로그)은 함께 보기 섹션에 링크된 Artisan 보안 및 운영 페이지에 있으며, 이 페이지는 관련 OWASP, CWE, NIST 조항을 고정해 둡니다.

이 가이드는 자체적으로 어떠한 규범적 표준 주장도 하지 않습니다. 브리지의 네트워크, 격리, 리소스 고갈 통제는 상위 Artisan 보안 및 운영 페이지에서 OWASP ASVS, CWE Top 25(SSRF / 통제되지 않은 리소스 소비), 그리고 NIST SP 800-53 SC-7에 매핑되어 있습니다. 이 쿡북 페이지는 사용법을 다시 설명하고, 해당 규범적 인용은 그 페이지로 미룹니다. 브리지는 어떠한 암호화 작업도 수행하지 않습니다 — 서명과 암호화는 코어 또는 상용 에디션의 영역이며 Artisan의 영향을 받지 않습니다.