13

Rozdział 13 z 21

Eksport artykułu do PDF

Opublikowany

Generujemy PDF artykułu przez Spatie Browsershot (headless Chromium sterowany Puppeteerem). Chromium renderuje żywy URL aplikacji (http://nginx/blog/{slug}), więc w PDF ląduje dokładnie to co w przeglądarce — z kolorowaniem kodu i obrazkami.

Czego się nauczysz

  • Spatie Browsershot i akcja /blog/{slug}/pdf
  • Render wewnętrznego URL (http://nginx, nie localhost)
  • Chromium w Alpine, noSandbox, zmienne PUPPETEER_*
  • Konfiguracja Dockerfile (chromium + puppeteer)

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}, nie localhost — Chromium działa wewnątrz kontenera, więc localhost wskazywał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łówkiem Content-Disposition: attachment, więc przeglądarka pobiera plik zamiast go otwierać.
  • {slug}/pdf przed {slug} — ta trasa (dwuczłonowa) i tak nie koliduje z akcją show (jednoczłonową), ale trzymamy show na 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 w setChromePath().
  • 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 showPdf renderująca wewnętrzny URL http://nginx/blog/{slug} (nie localhost), 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.

Narzędzia / paczki

Spatie Browsershot Chromium Puppeteer

Spis treści