Rozdział 13: Eksport artykułu do PDF
Ostatni element Części II — eksport artykułu do PDF. Wykorzystamy Spatie Browsershot, który steruje headless Chromium (przez Puppeteer) i renderuje żywą stronę artykułu do pliku PDF — z zachowaniem kolorowania kodu (Rozdz. 9), obrazków i mediów (Rozdz. 10–12).
Stan wejściowy: gotowa strona artykułu z bogatą treścią. Tu dokładamy akcję generującą PDF i przycisk „Pobierz PDF”.
Jak to działa
Browsershot nie „składa” PDF ręcznie — otwiera stronę w prawdziwej przeglądarce i drukuje ją do PDF:
PHP (Browsershot) → Node.js (Puppeteer) → Chromium (headless) → PDF
Chromium renderuje ten sam HTML/CSS/JS co przeglądarka użytkownika, więc w PDF ląduje dokładnie to, co widać na stronie — łącznie z Bootstrapem, podświetlaniem składni (highlight.js) i obrazkami. Zainstaluj paczkę:
docker compose exec app composer require spatie/browsershot
Akcja eksportu
Dodajemy akcję showPdf do BlogController. Kluczowe: Browsershot renderuje wewnętrzny URL aplikacji,
a nie składa treści samodzielnie:
// src/Controller/BlogController.php
use Spatie\Browsershot\Browsershot;
#[Route('/{slug}/pdf', name: 'show_pdf')]
public function showPdf(string $slug): Response
{
$article = $this->articles->findOneBy(['slug' => $slug]);
if (null === $article || BlogArticle::STATUS_PUBLISHED !== $article->getStatus()) {
throw new NotFoundHttpException(sprintf('Artykuł "%s" nie istnieje.', $slug));
}
$url = 'http://nginx/blog/' . $slug; // wewnętrzny adres w sieci Dockera
$pdf = Browsershot::url($url)
->setChromePath('/usr/bin/chromium-browser')
->noSandbox()
->waitUntilNetworkIdle()
->margins(15, 15, 15, 15)
->format('A4')
->pdf();
return new Response($pdf, Response::HTTP_OK, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => sprintf('attachment; filename="%s.pdf"', $slug),
]);
}
http://nginx/blog/{slug}, nielocalhost— Chromium działa wewnątrz kontenera, więclocalhostwskazywałby na sam kontener, nie na aplikację. W sieci Dockera serwis WWW jest dostępny pod nazwą usługi (nginx).->pdf()zwraca zawartość PDF jako string; oddajemy ją z nagłówkiemContent-Disposition: attachment, więc przeglądarka pobiera plik zamiast go otwierać.{slug}/pdfprzed{slug}— ta trasa (dwuczłonowa) i tak nie koliduje z akcjąshow(jednoczłonową), ale trzymamyshowna końcu kontrolera (zasada z Rozdziału 5).
Chromium i Puppeteer
Kilka wywołań Browsershota jest istotnych w środowisku serwerowym:
setChromePath('/usr/bin/chromium-browser')— wskazujemy binarkę Chromium zainstalowaną w obrazie (nie pobieramy jej przy każdym uruchomieniu).noSandbox()— sandbox Chromium nie działa w kontenerze bez dodatkowych uprawnień; wyłączamy go (bezpieczne, bo renderujemy własną, zaufaną stronę).waitUntilNetworkIdle()— czekamy, aż strona się w pełni załaduje (obrazki, CSS, JS/highlight.js), zanim zrobimy „wydruk”. Bez tego PDF mógłby powstać przed pokolorowaniem kodu.format('A4')+margins(...)— format i marginesy strony.
Konfiguracja Dockerfile
Browsershot potrzebuje Chromium i Puppeteera w obrazie. W Dockerfile (Alpine) instalujemy Chromium
i mówimy Puppeteerowi, żeby użył systemowej binarki zamiast pobierać własną:
# Chromium + zależności do renderowania
RUN apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont nodejs npm
# Puppeteer ma NIE pobierać własnego Chromium — użyje systemowego
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
# Puppeteer globalnie (Browsershot wywołuje go z Node.js)
RUN npm install -g puppeteer@latest --unsafe-perm
- Chromium w Alpine ląduje pod
/usr/bin/chromium-browser— dokładnie tę ścieżkę podajemy wsetChromePath(). PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true— nie pobieramy drugiego Chromium (oszczędność miejsca i czasu buildu).- Po zmianie zależności przebuduj obraz:
make stop && docker compose build && make fast.
Przycisk pobierania
Na stronie artykułu dodajemy link do eksportu:
<a href="{{ path('app_blog_show_pdf', { slug: article.slug }) }}" class="btn btn-outline-danger btn-sm">
<i class="fa-solid fa-file-pdf me-1"></i>Pobierz PDF
</a>
Kliknięcie generuje PDF „w locie” z aktualnej wersji artykułu — nie trzymamy plików PDF na dysku.
Wydajność: render przez Chromium jest kosztowny (uruchamia przeglądarkę). Przy dużym ruchu warto go cache’ować albo generować asynchronicznie (Messenger) — ale dla bloga generowanie na żądanie w zupełności wystarcza.
Podsumowanie
Artykuł można pobrać jako PDF:
- ✅ Spatie Browsershot (
composer require spatie/browsershot) — headless Chromium przez Puppeteer, - ✅ akcja
showPdfrenderująca wewnętrzny URLhttp://nginx/blog/{slug}(nielocalhost), z odpowiedziąattachment, - ✅ konfiguracja Chromium (
setChromePath,noSandbox,waitUntilNetworkIdle,A4, marginesy), - ✅ Dockerfile — Chromium w Alpine + Puppeteer (
PUPPETEER_*), ścieżka/usr/bin/chromium-browser, - ✅ działający stan: klikasz „Pobierz PDF” i dostajesz plik z pełną treścią — kod z kolorami i obrazki.
To koniec Części II — treść bloga jest bogata i przenośna: Markdown z podświetlaniem kodu, obrazki, wideo i audio, upload mediów oraz eksport do PDF.
W Części III (Rozdziały 14–15) dodamy wielojęzyczność — tłumaczenia artykułów (Translation Table Pattern, slug per język, hreflang) i kategorii. Zaczniemy w Rozdziale 14.